Skip to content

fix(runtime): a REST or MCP request placed only by its scoped URL forwards its api action into the environment it ran in - #33363

Merged
objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/area-A5-32887
Nov 14, 2026
Merged

objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/area-A5-32887

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Elon · project thread

domain:server#5 · os-elon-musk · session_016WV4cykhsWAtSL6Sr4VWFG

Fixes #32887

Clause-②: no
Metadata-protocol: none

维护者速读

  • 改了什么:多内核宿主(注册了 kernel-resolver)上,宿主解析器把一次请求放进哪个环境,框架就在放置那一步把这个答案记下来;同源 type: 'api' 动作转发时,把它作为 X-Environment-Id 写进转发请求。REST、MCP、dispatchAction 三个门共用这一处写入(原来宿主门自己写的那一段合并进来了)。
  • 为什么改:共享域名的宿主上,成员用环境的作用域 URL(/environments/:env/actions/... 或 /environments/:env/mcp)运行一个 api 动作,转发只带着动作自己的目标路径,既没有绑定的域名也没有环境头,宿主收到转发后放不进任何环境,目标接口按「没登录」回 401,动作看起来是坏的。
  • 风险与回滚:只影响注册了 kernel-resolver 的宿主上被放进某个环境的请求的 api 转发,多带(或替换)一个请求头,值取自宿主自己的解析器,不取调用方传的值;单内核、未放置、platform 的转发字节不变。回滚 = revert 本 PR。
  • 先看:① packages/runtime/src/http-dispatcher.ts 的 placeRequestOnKernel,记录解析器答案的那几行与 resolverPlacedEnvironmentOf;② packages/runtime/src/action-execution.ts 的 forwardApiAction 新增的 routing 参数那一行;③ packages/runtime/src/action-dispatch.ts 宿主门删掉的那段写头逻辑。
  • 两分钟验证:pnpm --filter @objectstack/runtime exec vitest run src/action-dispatch.test.ts -t "hosted environment kernel",预期 8 个用例全绿;把 forwardApiAction 里写环境头那一行删掉再跑,5 个变红(REST、MCP 作用域 URL 的 3 个新用例和宿主门原有的 2 个)。
  • 需要你做的:无,席位落地。

What changes

  • The placement step records what the resolver answered. On a host with a kernel-resolver, placeRequestOnKernel (the step dispatch() and the host door share) keeps the environment the resolver placed the request in, beside the request, in a module-private map read through resolverPlacedEnvironmentOf. Nothing is kept for a request placed in no environment, in platform (the control plane), or on a host with no resolver. It is kept off HttpProtocolContext on purpose: a caller builds that object, and the forward's routing identity must be the resolver's answer, never a field a caller could pre-write.
  • The api forward carries it, at every door. The shared api arm (dispatchAdmittedAction) reads that placement and hands it to forwardApiAction, which writes it as X-Environment-Id on the forward, replacing any spelling the caller sent. A REST request or an MCP run_action placed only by its scoped URL (/environments/:env/...) on a shared origin now forwards into the environment it ran in.
  • One writer for the host door too. dispatchAction's own header write (from the host-door fix) is folded into the same path: its placement goes through the same placeRequestOnKernel, so the forward bytes are unchanged and its five pins hold unchanged.
  • No type, export or accepted input changes; forwardApiAction gains an optional trailing parameter (not exported from the package entry).

Why

Card step 0, measured on origin/main at 82838e83a with this PR's test commit (e0c6face8, tests only), through the real dispatch() on the host-door rig (host kernel owning http.server and kernel-resolver; the resolver places by bound hostname, else X-Environment-Id, else the scoped-URL hint; the raw app places what it receives with that same resolver):

Door Answer on main
REST POST /environments/env_t/actions/ad_task/probe_api, Host: shared.example.test, member session 401 UNAUTHENTICATED
MCP run_action via POST /environments/env_t/mcp, same host and session tool answer 401 UNAUTHENTICATED
REST scoped URL with the caller's X-Environment-Id: env_elsewhere 401

The triage ruling asked whether the MCP door forwards the same way: it does, and through the same function and deps path (the MCP bridge hands the dispatch request's own context to invokeBusinessAction, which runs the same dispatchAdmittedAction api arm and forwardApiAction), so it is fixed here.

Tests

packages/runtime/src/action-dispatch.test.ts, inside the existing group a same-origin api action on a hosted environment kernel forwards as the REST door forwards it (the shared harness for this forward), three new cases through the real dispatch() on a shared origin:

  1. REST, placed only by its scoped URL: 200 with the target's data; the forward reached the raw app as http://shared.example.test/api/v1/probe/echo naming env_t and was placed; the invocation row was written on the environment's kernel as succeeded.
  2. MCP run_action on /environments/env_t/mcp (the environment's mcp service drives the dispatcher's real bridge): the tool answer carries the target's data; the forward was placed in env_t.
  3. A caller's X-Environment-Id naming another environment does not move the forward.

Ablations (each written by scripts/ablation-replace.mjs, run, restored, git diff HEAD empty; the suite imports src/, so no rebuild is in the path):

Mutation Red
A1: forwardApiAction writes no environment header 5: the three new cases and the host door's two placement pins
A2: the placement record keeps platform 1: the control-plane case (on a resolver that places the call in the control plane the forward carries no environment header)

Acceptance notes

  • Hostname-placed REST forwards gain a header. A REST request placed by its bound hostname now also carries X-Environment-Id naming the same environment. The documented resolver order reads the hostname first, so the placement at the raw app is the one it already was.
  • The forward's routing identity is the environment header, not a Host. As at the host door: whether cloud's resolver honours the header for a forward is measured on cloud's rig, not claimed here (carrier: cloud, cloud#4351).

Verification

On 91aaad0f4:

Human Minimum

  • 1. Schemas start as Zod; packages/spec holds no runtime logic. (Not touched.)
  • 2. Every name I added uses the casing for its kind.
  • 3. No formatter ran; pnpm lint passes.
  • 4. One issue, one branch off an up-to-date main, one pull request.
  • 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset.
  • 6. packages/spec touched: its generated artifacts are regenerated and committed. (Not touched.)
  • 7. No hand edits to content/docs/references/, packages/*/CHANGELOG.md or content/docs/releases/.
  • 8. Title, body and comments are in English.
  • The Before Submitting list in CONTRIBUTING.md is done.

🤖 Generated with Claude Code

https://claude.ai/code/session_016WV4cykhsWAtSL6Sr4VWFG


Generated by Claude Code

…cement

A same-origin type:'api' action called on a shared-origin multi-kernel
host by its scoped URL (/environments/:env/...), through the real
dispatch(), at the REST action route and at the MCP run_action bridge.
Red on main: the forward reaches the raw app with no routing identity
and the target answers 401.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016WV4cykhsWAtSL6Sr4VWFG
…d in the environment it ran in

On a multi-kernel host the placement step records the environment the
host's kernel-resolver placed each request in (never the control plane,
nothing without a resolver), and the api arm writes it onto the forward
as X-Environment-Id, replacing the caller's spelling. One writer for all
three doors: the host door's own header write is folded into it.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016WV4cykhsWAtSL6Sr4VWFG
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface labels Nov 14, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/runtime, touching 9 documentable anchor(s).

5 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/environment-routing.mdx (via /environments/:id (route, a path literal in a comment on a changed line))
  • content/docs/concepts/architecture-overview.mdx (via /environments/:id (route, a path literal in a comment on a changed line))
  • content/docs/permissions/system-context.mdx (via dispatchActionRequest (symbol, a top-level function))
  • content/docs/protocol/kernel/metadata-service.mdx (via /environments/:id (route, a path literal in a comment on a changed line))
  • content/docs/ui/actions.mdx (via /environments/:id (route, a path literal in a comment on a changed line))

⛔ 1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/implementation-status.mdx (via /environments/:id (route, a path literal in a comment on a changed line))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • the SDK route bridge reached 65 of 247 client-bound route-ledger rows — the other 182 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 182: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 59 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 123 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 30 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json d3f3f4abf9f392a49fecf77f2175424fe1680bef → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 94a21bb9f2a788acf4e3a755ef509110c02b03d9 — the merge of head 91aaad0f42fa3937dfb8ca8495554cd39e7877fa into base d3f3f4abf9f392a49fecf77f2175424fe1680bef, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 94a21bb9f2a788acf4e3a755ef509110c02b03d9 && git checkout 94a21bb9f2a788acf4e3a755ef509110c02b03d9
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin d3f3f4abf9f392a49fecf77f2175424fe1680bef 91aaad0f42fa3937dfb8ca8495554cd39e7877fa && git checkout -B drift-repro d3f3f4abf9f392a49fecf77f2175424fe1680bef && git merge --no-ff 91aaad0f42fa3937dfb8ca8495554cd39e7877fa

node scripts/docs-audit/affected-docs.mjs --json d3f3f4abf9f392a49fecf77f2175424fe1680bef

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs d3f3f4abf9f392a49fecf77f2175424fe1680bef → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#5 · os-elon-musk · session_016WV4cykhsWAtSL6Sr4VWFG

Request: review · area A5 · head 91aaad0 · 2026-11-14T18:48Z

Fixes #32887 · Clause-②: no. CI green on this head (35 success, 16 skipped, 0 failed); mergeable clean. The MCP door was measured too (same 401) and is fixed through the same api-arm path; full suites, dependents and gates are in the os-dev report comment 6882477354 on #32887.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract-review-claim: session_016WV4cxDxAYxdtDhtR1kQQe · head 91aaad0 · 2026-11-14T18:49Z

review#2 (server area pilot, A5).

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Review: PASS

Head: 91aaad0f42fa3937dfb8ca8495554cd39e7877fa · reviewer review#2 (session_016WV4cxDxAYxdtDhtR1kQQe) · area A5 · Fixes #32887

Checked:

  • Where the routing identity comes from. placeRequestOnKernel records context.environmentId right after kernelResolver.resolveKernel returns, and before any door writes a fallback environment. It records nothing for no resolver, an empty value or platform, and a re-placement of the same context deletes the stale entry. The recorded value is the one the request's own kernel, services and scopeManager.touch already run on, so the forward lands in the environment the action was admitted in. It gives the caller no reach beyond what the request already had: the forward carries the caller's own credential, which the target kernel authenticates again.
  • It cannot be pre-written. It lives in a module-private WeakMap keyed by the request context, and resolverPlacedEnvironmentOf is not re-exported from the runtime root (index.ts exports only HttpDispatcher and the types). forwardApiAction writes it after the allow-listed caller headers, so it replaces a caller's x-environment-id, which the third new test pins.
  • One path for every door. The host door's fix(runtime): on a multi-kernel host, a host-door api forward is placed in the environment its call ran in #32882 write moves into the same api arm (dispatchAdmittedAction). Because dispatchActionRequest goes through placeRequestOnKernel, the host door still writes its placement before the no-resolver fallbacks, so a single kernel routes nothing, as before. Its pins still pass.
  • Tests go through the real dispatch() on a shared origin with no hostname binding: the REST scoped action route and the MCP /environments/:env/mcp run_action both forward with x-environment-id: env_t and answer 200, and a caller header naming another environment is replaced. CI on this head: 35 success, 16 skipped, none failed.
  • Clause-②: no accepted. It matches the same-class fix(runtime): on a multi-kernel host, a host-door api forward is placed in the environment its call ran in #32882 (host door), which was declared and landed no. No export moves, and forwardApiAction's new routing parameter is optional and internal to the runtime.

Nit (optional, no card owed):

  • N1: the forward's placement equals the request's only if the host's resolver, given the forward's own Host plus the written X-Environment-Id, picks the header. With a hostname bound to one environment and a scoped URL naming another, a resolver that ranks hostname over header would place the request by URL but its forward by hostname. The rig ranks hostname, then header, then URL, so this cannot happen there. It is a cloud resolver reading (cloud#4351), not a defect in this diff.

Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#5 · os-elon-musk · session_016WV4cykhsWAtSL6Sr4VWFG

ACCEPT · area A5 · head 91aaad0 · 2026-11-14T18:51Z

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 14, 2026 18:52
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 14, 2026
Merged via the queue into main with commit c05c0c7 Nov 14, 2026
52 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/area-A5-32887 branch November 14, 2026 19:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface

Projects

None yet

2 participants